All files / web/src/app/api/integrations/kid-songs/[playerId]/audio route.ts

95.45% Statements 63/66
88.23% Branches 15/17
100% Functions 2/2
95.45% Lines 63/66

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 671x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 8x 8x 8x 8x 8x 8x 1x 9x 9x 9x 9x 9x 9x 9x 9x 9x 9x 9x 9x 9x 4x 4x 9x 4x 4x 9x 9x 3x 3x 3x 3x 3x 9x 1x 1x 1x 9x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 1x 9x       9x  
import { authorizeKidSongsRequest } from '@/lib/auth/integrationToken'
import { readAndHashSong } from '@/lib/kid-songs/audio'
import {
  isValidAudioVersion,
  isValidId,
  toEligibleSongCandidate,
} from '@/lib/kid-songs/eligibility'
import { getSongCandidateById } from '@/lib/kid-songs/queries'
 
export const runtime = 'nodejs'
export const dynamic = 'force-dynamic'
 
const commonHeaders = { 'Cache-Control': 'no-store', Vary: 'Authorization' }
function json(body: unknown, status: number): Response {
  return new Response(JSON.stringify(body), {
    status,
    headers: { ...commonHeaders, 'Content-Type': 'application/json' },
  })
}
 
export async function GET(
  request: Request,
  context: { params: Promise<{ playerId: string }> }
): Promise<Response> {
  try {
    const auth = authorizeKidSongsRequest(request)
    if (!auth.configured || !auth.tokenAccepted) return json({ error: 'Unauthorized' }, 401)
 
    const { playerId } = await context.params
    const search = new URL(request.url).searchParams
    const songId = search.get('songId')
    const version = search.get('v')
    if (!isValidId(playerId) || !isValidId(songId) || !isValidAudioVersion(version)) {
      return json({ error: 'Invalid parameters' }, 400)
    }
    if (!auth.allowedPlayerIds.has(playerId)) return json({ error: 'Not found' }, 404)
 
    const row = await getSongCandidateById(playerId, songId)
    const candidate = row ? toEligibleSongCandidate(row) : null
    if (!candidate) return json({ error: 'Not found' }, 404)
 
    let audio
    try {
      // readAndHashSong performs one whole-file read; its ID guard protects the join.
      audio = await readAndHashSong(songId)
    } catch (error) {
      console.error(`[kid-songs] Audio unavailable for ${songId}:`, error)
      return json({ error: 'Audio unavailable' }, 503)
    }
    if (audio.sha256 !== version) return json({ error: 'Not found' }, 404)
 
    return new Response(new Uint8Array(audio.bytes), {
      status: 200,
      headers: {
        ...commonHeaders,
        'Content-Type': 'audio/mpeg',
        'Content-Length': String(audio.bytes.byteLength),
        'Content-Disposition': `inline; filename="${songId}.mp3"`,
        'X-Content-SHA256': version,
      },
    })
  } catch (error) {
    console.error('[kid-songs] Audio route failed:', error)
    return json({ error: 'Internal error' }, 500)
  }
}